Fix #185: revive exited apps on access via a real-state start primitive#186
Merged
Conversation
The 5s throttle on the app-start primitive was keyed on nothing, so a start of app A silently dropped a start of app B within the window. The wake path worked around this by calling docker_unpause_app directly to dodge the throttle. Keying the throttle on the call's positional args makes each app throttle independently, so one revive primitive can serve every wake without dropping cross-app calls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With pause_enabled, the wake-on-access path dispatched on the stored app status: PAUSED woke via docker_unpause_app (compose unpause), everything else via docker_start_app. When an app was exited (core-upgrade converge stop, crash, OOM) while the database still said PAUSED, compose unpause errored 'container is not paused', the revive task crashed, and the app 502-looped forever (issue #185). Replace the status-driven dispatch with start_app: one idempotent primitive that decides from the real container state, not the stored status. A paused stack unfreezes, anything exited/created/missing is brought up with compose, and an already-running stack is a no-op. When the stored status and the real state disagree it still does the right thing and logs a warning, per review. Both wake-on-access and the always-on control tick now call start_app only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover start_app's branches (exited-while-db-says-PAUSED revives via up -d rather than crashing on unpause, genuine pause unfreezes, running is a no-op that reconciles status, missing stack starts), get_app_container_state's categorization, the wake path routing every status through start_app, and the per-argument throttle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review panel found the new start_app dropped the old status allow-list, so the always-on control tick could revive an app mid-uninstall/reinstall or in ERROR and recreate orphaned containers. Restore the guard: start_app acts only on STOPPED/RUNNING/DOWN/PAUSED, with the real-state dispatch inside the gate so the #185 fix (revive an exited app the db still calls PAUSED) is preserved. Also harden the revive: a partially-paused stack whose unpause errors now falls back to up -d instead of crashing (which would relocate the 502-loop); the docker-inspect call is inside the try so an inspect race degrades to 'missing' rather than propagating. Extract _do_unpause to drop the duplication between start_app and docker_unpause_app, type the container state as a Literal, and trim the bug history out of the docstring (it lives in this commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e detector Cover the non-revivable-status gate, the unpause-fails fallback to up -d, the stale-network/conflict recovery in _compose_up, a paused+exited mixed detector case, and a real-docker test that get_app_container_state classifies a freshly installed (created) container as needing a start. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Controller main added ConfigOverrideKey/ConfigOverrideRequest and the config_overrides_desired/applied fields (operator-set config overrides, incl. PAUSE_ENABLED). The vendored copy under data_model/backend went stale, so the types-drift check fails on every open PR until a merging PR carries the resync. Mechanical 'just get-types' output; DO-NOT-MODIFY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new start_app reconcile recreates a missing/exited container for an app whose db status is still revivable. A wake-on-access revive (detached task from ensure_app_is_running) or the always-on control tick could therefore run compose up after the uninstall worker already removed the containers, leaving an orphaned container that survives uninstall — test_uninstall_running_app flakily failed with 'DID NOT RAISE NotFound'. Add a per-app asyncio lock (mirrors backup.py's BACKUP_IN_PROGESS_LOCK) that both start_app and _uninstall_app hold: a revive now blocks until teardown finishes, then reads status=None and skips. Closes the TOCTOU the status gate alone couldn't (status read, then container removed, then compose up). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #185.
Problem
With
apps.lifecycle.pause_enabled=true, an app that is exited (not paused) was never revived on access — it 502-looped forever. The wake-on-access path dispatched on the stored app status:PAUSEDwoke viacompose unpause, everything else via the start path. When an app exited out-of-band (core-upgrade converge stop, crash, OOM) while the database still saidPAUSED,compose unpauseerroredcontainer is not paused, the revive task crashed, and the app never started (first hit in production on shard c0p3x5, diagnostic8aac62f1).Fix
One idempotent revive primitive,
start_app, that decides from the real container state (get_app_container_stateviadocker compose ps+docker inspect), not the stored status:unpause(falls back toup -dif the stack can't be unpaused)up -dIt acts only on a revivable app (
STOPPED/RUNNING/DOWN/PAUSED) and logs a warning whenever the db status and the real container state disagree (per the issue's follow-up comment about defensiveness). Both wake-on-access and the always-on control tick now callstart_apponly.The
@throttledecorator was changed from a single global timestamp to per-argument, so throttling one app's revive can no longer silently drop another app's wake — which is what forced the old wake path to special-casePAUSEDto dodge the global throttle.Recommended reading order
shard_core/util/misc.py— throttle → per-argumentshard_core/service/app_tools.py—get_app_container_state,start_app,_do_unpause/_compose_up/_mark_runningshard_core/service/app_lifecycle.py— route wake + always-on throughstart_appagents.md— note the revive primitivetest_throttle.py,test_app_compose_pinning.py,test_app_lifecycle_pause.py,test_app_lifecycle.pyReview panel
Adversarial reviewer — 1 BLOCKING, addressed:
start_appdropped the old status allow-list; the always-on control tick could revive an app mid-uninstall/reinstall or in ERROR and recreate orphaned containers. Fixed (commit 9f03ca6): restored an explicit_REVIVABLE_STATUSgate (STOPPED/RUNNING/DOWN/PAUSED) with the real-state dispatch inside it; addedtest_start_app_skips_non_revivable_status.unpauseleaves the exited sibling down or errors, re-creating the 502-loop (advisory). Fixed: the paused branch now falls back toup -difunpauseerrors, so it can't crash-loop; addedtest_start_app_falls_back_to_up_when_unpause_fails. (The mixed state itself is pathological —compose pauseis atomic — so full multi-container reconciliation is left out per simplicity.)docker inspectrace could propagate instead of degrading to "missing" (advisory). Fixed: both docker calls are now inside thetry.ps+inspectno-op, and single-container apps (the production case) aren't affected.Test adversary — no blocking, gaps addressed:
get_app_container_statenever exercised against real docker. Fixed:test_get_app_container_state_reads_real_created_containerinstalls an app and asserts the detector classifies its created container as needing a start (verified locally)._compose_uprecovery paths untested. Fixed: added parametrized cases andtest_start_app_recovers_stale_containers.DevEx / readability — advisory only:
start_app's paused branch anddocker_unpause_app. Fixed: extracted_do_unpause.ContainerStateLiteral, trimmed docstring.start_appdrops thedocker_prefix. Kept deliberately — it's the higher-level orchestrator vs. the thindocker_*CLI wrappers (matches the issue's proposed name).CI fix: revive vs. uninstall race (commit 233858d)
First CI run surfaced a real regression the local run had misattributed to the port-80 collision:
test_uninstall_running_appfailed withDID NOT RAISE NotFound. Root cause — the newstart_appreconcile recreates a missing/exited container for an app whose db status is still revivable, and the detached wake task (ensure_app_is_running) / always-on control tick could runcompose upafter the uninstall worker removed the containers, leaving an orphan that survives uninstall. The_REVIVABLE_STATUSgate alone couldn't close it (status read, then container removed, then compose up = TOCTOU).Fix: a per-app
asyncio.Lock(mirrorsbackup.py'sBACKUP_IN_PROGESS_LOCK) held by bothstart_appand_uninstall_app, so a revive blocks until teardown finishes then reads status=None and skips. Addedtest_start_app_is_serialized_by_the_per_app_op_lock(falsifiable: fails without the lock).Verification
Full unit suite green locally (57 app-lifecycle/compose/throttle tests incl. the new lock test). The two port-80 integration tests (
test_app_starts_and_stops,test_uninstall_running_app) can't run on the dev VM (host port 80 held by infra); verified via CI, which has port 80 free.CI: vendored type resync (commit 74e608f)
Controller
mainaddedConfigOverrideKey/ConfigOverrideRequestandconfig_overrides_desired/appliedafter the last shard_core merge, sotypes-driftwent red on every open PR. This branch carries the mechanicaljust get-typesresync (onlydata_model/backend/shard_model.py, +26/-1, DO-NOT-MODIFY vendored) so it merges drift-free. Unrelated to the pause-revive fix — split into its own PR if preferred.